<?php
/*********************************************
 *  CPG Dragonfly™ CMS
 *********************************************
	Copyright © since 2010 by CPG-Nuke Dev Team
	http://dragonflycms.org

	Dragonfly is released under the terms and conditions
	of the GNU GPL version 2 or any later version

	wiki.zope.org/ZPT/TAL
*/

namespace Poodle\TPL;

class Parser
{
	public
		$data,
		$errors;

	private
		$parser,
		$level, // How deep the current rabbit hole goes
		$skip,  // Skip inner nodes
		$nodes,
		$character_data,
		$tal_conditions, // tal:condition level
		$xsl_foreach,    // xsl:for-each level
		$xsl_choose,

		$file = null,
		$line = 0,

		$DTD = null,
		$HTML = false;

	private static
		$form_elements = array('button','input','select','textarea'),
		$form_methods  = array('reset','submit'),
		$uri_attribs   = array('action','formaction','href','src','poster'),
		$table_struct  = array('thead','tbody','tfoot');

	function __construct() { $this->free(); }
	function __destruct()  { $this->free(); }

	public function create()
	{
		if ($this->parser) { return; }
		// Create and initialize parser
		$this->parser = xml_parser_create('UTF-8');
		xml_parser_set_option($this->parser, XML_OPTION_CASE_FOLDING, false);
		xml_parser_set_option($this->parser, XML_OPTION_SKIP_WHITE, false);
		xml_parser_set_option($this->parser, XML_OPTION_TARGET_ENCODING, 'UTF-8');

		xml_set_object($this->parser, $this);
		xml_set_character_data_handler($this->parser, 'xml_character_data');
		xml_set_element_handler($this->parser, 'xml_node_start', 'xml_node_end');
/*
		xml_set_default_handler($this->parser, 'xml_default_handler');
		xml_set_end_namespace_decl_handler($this->parser, 'xml_end_namespace_decl');
		xml_set_processing_instruction_handler($this->parser, 'xml_processing_instruction');
		xml_set_start_namespace_decl_handler($this->parser, 'xml_start_namespace_decl');
		xml_set_unparsed_entity_decl_handler($this->parser, 'xml_unparsed_entity_decl');
		# xml_set_external_entity_ref_handler — Set up external entity reference handler
		# xml_set_notation_decl_handler — Set up notation declaration handler
*/
		xml_parse($this->parser, '<tal>', false);
	}

	public function free($skip_error=false)
	{
		if ($this->parser) {
			if (!xml_parse($this->parser, '</tal>', true) && !$skip_error)
			{
				$this->log_error(xml_error_string(xml_get_error_code($this->parser)));
			}
			xml_parser_free($this->parser);
			$this->parser = null;
		}
		$this->line  = 0;
		$this->level = 0;
		$this->skip  = 0;
		$this->nodes = array();
		$this->tal_conditions = array();
		$this->xsl_foreach = array();
		$this->xsl_choose = array();
		$this->character_data = '';
	}

	public function load_dtd($type)
	{
		include(__DIR__.'/dtd/'.$type.'.php');
		if (isset($DTD)) { $this->DTD = $DTD; }
		$this->HTML = stripos($this->doctype(),'DOCTYPE html');
	}

	public function doctype() { return ($this->DTD && isset($this->DTD['DOCTYPE']) ? $this->DTD['DOCTYPE']."\n" : null); }

	public function isXML()   { return ($this->DTD && !empty($this->DTD['XML'])); }

	public function isCaseInsensitive() { return ($this->DTD && !empty($this->DTD['CASE-INSENSITIVE'])); }

	public function parse_file($filename, $final=true)
	{
		$this->file = $filename;
		return self::parse_string(file_get_contents($filename), $final);
	}

	public function parse_string($data, $final=true)
	{
		$this->create();
		$this->data   = '';
		$this->errors = array();
		$data = preg_replace('#<(\?xml|\!DOCTYPE)[^>]*>\r?\n?#s','',$data);
		if (xml_parse($this->parser, $data, false)) {
			if ($final) { $this->free(); }
			else { $this->line = xml_get_current_line_number($this->parser); }
			$this->data = preg_replace('#\R#', "\n", $this->data);
			$this->data = preg_replace('#>[ \t]+<#','> <',$this->data);
			$this->data = preg_replace('#\s+\?><\?php\s+#',' ',$this->data);
		} else {
			$this->log_error(xml_error_string(xml_get_error_code($this->parser)));
			$this->free(true);
		}
		return empty($this->errors);
	}

	protected function DTD_has_tag($name) { return (!$this->DTD || isset($this->DTD[$name])); }

	protected function DTD_has_tag_attribute($tag, $attr)
	{
		// 'data-*' allowed in HTML5
		return (!$this->DTD
			|| isset($this->DTD[$tag][$attr])
			|| isset($this->DTD['STANDARD_ATTRIBUTES'][$attr])
			|| 0===strpos($attr,'data-'));
	}

	protected function DTD_is_empty_tag($name) { return ($this->DTD && isset($this->DTD['EMPTY-TAGS']) && in_array($name, $this->DTD['EMPTY-TAGS'])); }

	protected function log_error($msg)
	{
		$this->errors[] = array(
			'message'=> $msg,
			'file'   => $this->file,
			'line'   => xml_get_current_line_number($this->parser) - $this->line,
//			'column' => xml_get_current_column_number($this->parser),
			'node'   => $this->nodes[count($this->nodes)-1]
		);
	}
	protected function error($msg, $type=E_USER_WARNING)
	{
		\Poodle\Debugger::error($type, $msg, $this->file, xml_get_current_line_number($this->parser) - $this->line);
	}

	// XML Parser: Node value
	protected function xml_character_data($parser, $data)
	{
		$this->character_data .= $data;
	}

	protected function push_character_data($data=null, $encode=true)
	{
		if (isset($data)) {
			$this->data .= $data;
//		} else if ($encode && false !== strpbrk($this->character_data,'&<>')) { slower ???
		} else if ($encode && (false!==strpos($this->character_data,'&')
		 || false!==strpos($this->character_data,'<')
		 || false!==strpos($this->character_data,'>'))
		) {
			$this->data .= '<?php static::echo_data('.self::data2Str($this->character_data).');?>';
		} else {
			$this->data .= $this->character_data;
		}
		$this->character_data = '';
	}

	// XML Parser: Node start
	protected function xml_node_start($parser, $name, $attribs)
	{
		$level = $this->level++;
		if ($this->skip && $level > $this->skip) { return; }
		if ($this->HTML || $this->isCaseInsensitive()) {
			$name = strtolower($name);
		}
		if ('tal' === $name) { return; }

		/** XSLT */
		if ($this->xslt_node_start($name, $attribs, $level)) { return; }

		if (!self::DTD_has_tag($name)) {
			if (!isset($attribs['tal:omit-tag'])) {
				$this->error("Omit unknown node '{$name}'");
			}
			$attribs['tal:omit-tag'] = '';
		}

		if ($this->HTML && 'tr' === $name) {
			if (!in_array($this->nodes[count($this->nodes)]['name'],self::$table_struct)
			 && !in_array($this->nodes[count($this->nodes)-1]['name'],self::$table_struct))
			{
				$this->error("Table TR started outside thead, tbody or tfoot");
			}
		}

		$this->push_character_data(/*isset($attribs['tal:omit-tag'])?'':null*/);

		$code = array();
		$node = array(
			'name'   => $name,
			'file'   => $this->file,
			'line'   => xml_get_current_line_number($parser) - $this->line,
//			'column' => xml_get_current_column_number($parser),
			'php'    => array(),
			'value'  => null,
			'omit-tag'=>false
		);

		/**
		 * TAL
		 * Attribute execution order:
		 *     define
		 *     condition
		 *     repeat
		 *     content or replace
		 *     attributes
		 *     omit-tag
		 */

		unset($attribs['xmlns:i18n']);
		unset($attribs['xmlns:metal']);
		unset($attribs['xmlns:tal']);
		unset($attribs['xmlns:xsl']);

		if (isset($attribs['tal:content']) && isset($attribs['tal:replace']))
		{
			$this->error("'tal:content' and 'tal:replace' attributes may not appear together.");
			unset($attribs['tal:replace']);
		}
		if (isset($attribs['tal:repeat']) && isset($attribs['tal:for']))
		{
			$this->error("'tal:repeat' and 'tal:for' attributes may not appear together.");
			unset($attribs['tal:for']);
		}

		if (isset($attribs['tal:define']) && preg_match_all('#([a-z][0-9a-z_]+)\s+([^;]*)(?:$|;)#D', $attribs['tal:define'], $m, PREG_SET_ORDER))
		{
			foreach ($m as $def) {
				$code[] = '$ctx->'.$def[1].' = ('.self::parse_expression($def[2]).');';
			}
		}

		if (isset($attribs['tal:else'])) {
			if (isset($this->tal_conditions[$level])) {
				$code[] = 'else {';
				$node['php'][] = '}';
				unset($this->tal_conditions[$level]);
			}
		} else
		if (isset($attribs['tal:else-if'])) {
			if (isset($this->tal_conditions[$level])) {
				$code[] = 'else if ('.self::parse_expression($attribs['tal:else-if']).') {';
				$node['php'][] = '}';
			}
		} else
		if (isset($attribs['tal:condition'])) {
//			if (!empty($this->tal_conditions[$level])) { $code[] = ' else'; }
			$code[] = 'if ('.self::parse_expression($attribs['tal:condition']).') {';
			$node['php'][] = '}';
			$this->tal_conditions[$level] = true;
		}

		if (isset($attribs['tal:repeat'])) {
			list($var, $exp) = self::split_var_from_expression($attribs['tal:repeat']);
			$code[] = self::create_foreach($var, $exp);
			$node['php'][] = '} $ctx = $ctx->parent;';
		}

		/**
		 * Replace content including start and end tags
		 * else continue processing
		 */

		$tag = '';
		if (isset($attribs['tal:replace']))
		{
			$this->skip = $level;
			$node['omit-tag'] = true;
			$node['value'] = self::getValue($attribs['tal:replace'], $attribs);
		}
		else
		{
			/**
			 * Replace inner content
			 */

			if (isset($attribs['tal:content'])) {
				$node['value'] = self::getValue($attribs['tal:content'], $attribs);
				$this->skip = $level;
			}

			$echo_attribs = array();
			if (isset($attribs['tal:attributes']) && preg_match_all('#([a-z\-]+)\s+([^;]*)(?:$|;)#D', $attribs['tal:attributes'], $m, PREG_SET_ORDER))
			{
				foreach ($m as $attr) {
					$echo_attribs[$attr[1]] = $attr[2];
				}
			}

			/**
			 * Check for required attributes
			 */

			if ($this->DTD && !empty($this->DTD[$name])) {
				foreach ($this->DTD[$name] as $attr => $required) {
					if (0 !== $required && !isset($attribs[$attr])) {
						if (!isset($echo_attribs[$attr])) {
							$this->error("Adding missing required attribute '{$attr}' to node '{$name}'", E_USER_NOTICE);
						}
						$attribs[$attr] = $required;
					}
				}
				// Set type="submit" because this is default for all browsers, except Internet Explorer
				if ('button'==$name && !isset($attribs['type'])) $attribs['type'] = 'submit';
			}

			/**
			 * Make valid attributes list
			 */

			$tag_attribs = array();
			foreach ($attribs as $attr => $value) {
				if (0 === strpos($attr,'tal:') || 0 === strpos($attr,'i18n:')) { continue; }
				if (strpos($attr,':') || self::DTD_has_tag_attribute($name, $attr)) {
					// If a form control (such as a submit button) has a name or id of submit it will mask the form's submit method.
					if (('name' === $attr || 'id' === $attr) && in_array($value, self::$form_methods) && in_array($name, self::$form_elements)) {
						$this->error("{$name}'s attribute '{$attr}' value '{$value}' changed to 'form-{$value}' or it will mask the form's method");
						$value = 'form-'.$value;
					}
					if (in_array($attr,self::$uri_attribs) && preg_match('#^(cdn://|\\?|/)#',$value)) {
						$value = \Poodle\URI::resolve($value);
					}
					$tag_attribs[$attr] = $value;
				}
			}

			foreach ($echo_attribs as $k => $v) {
				$echo_attribs[$k] = self::parse_expression($v, isset($tag_attribs[$k])?self::data2Str($tag_attribs[$k]):null);
				if (in_array($k,self::$uri_attribs) && false === stripos($echo_attribs[$k], 'static::resolveURI')) {
					$echo_attribs[$k] = 'static::resolveURI('.$echo_attribs[$k].')';
				}
			}

			/**
			 * http://wiki.zope.org/zope3/ZPTInternationalizationSupport
			 */
			if (isset($attribs['i18n:domain'])) { trigger_error('i18n:domain not supported'); }
			if (isset($attribs['i18n:source'])) { trigger_error('i18n:source not supported'); }
			if (isset($attribs['i18n:target'])) { trigger_error('i18n:target not supported'); }
			if (isset($attribs['i18n:name']))   { trigger_error('i18n:name not supported'); }
			if (isset($attribs['i18n:data']))   { trigger_error('i18n:data not supported'); }

			if (isset($attribs['i18n:translate'])) {
				$attribs['i18n:translate'] = trim($attribs['i18n:translate']);
				if (strlen($attribs['i18n:translate'])) {
					$node['value'] = '<?php static::echo_data('.self::l10n($attribs['i18n:translate']).'); ?>';
				}
				else { $node['translate'] = true; }
			}
			if (isset($attribs['i18n:attributes']) && preg_match_all('#([a-z\-]+)(\s+[^;]*)?(?:$|;)#D', $attribs['i18n:attributes'], $m, PREG_SET_ORDER)) {
				foreach ($m as $attr) {
					$attr[2] = isset($attr[2]) ? trim($attr[2]) : '';
					if (empty($attr[2])) {
						// TODO: translate tal:attributes value
						if (isset($attribs[$attr[1]])) {
							$attr[2] = self::data2Str($attribs[$attr[1]]);
						} else {
							$this->error("Undefined attribute '{$attr[1]}' in node '{$name}'", E_USER_NOTICE);
							continue;
						}
					}
					$echo_attribs[$attr[1]] = self::l10n($attr[2], true);
				}
			}

			if (isset($tag_attribs['xml:lang']) && !isset($tag_attribs['lang'])) {
				$tag_attribs['lang'] = $tag_attribs['xml:lang'];
			}
			if (isset($tag_attribs['lang']) && self::DTD_has_tag_attribute('STANDARD_ATTRIBUTES', 'xml:lang')) {
				$tag_attribs['xml:lang'] = $tag_attribs['lang'];
				unset($tag_attribs['lang']);
			} else {
				unset($tag_attribs['xml:lang']);
			}

			if ($echo_attribs) {
				if (!isset($echo_attribs['type']) && 'input'===$name) {
					$echo_attribs['type'] = "'{$tag_attribs['type']}'";
					unset($tag_attribs['type']);
				}
				$a = array();
				foreach ($echo_attribs as $attr => $value) {
					if (preg_match('#^on((dbl)?click|mouse|key|(un)?load|abort|error|resize|scroll|select|change|submit|reset|focus|blur|dom*).*#', $attr)) {
						$this->error("Obsolete attribute '{$attr}' in node '{$name}'", E_USER_DEPRECATED);
					} else if (!strpos($attr,':') && !self::DTD_has_tag_attribute($name, $attr)) {
						$this->error("Unknown attribute '{$attr}' in node '{$name}'", E_USER_NOTICE);
					}
					$a[] = "'$attr'=>$value";
					unset($tag_attribs[$attr]);
				}
				if ('input'===$name||'option'===$name) {
					$fn = 'echo_input_attributes';
				} else {
					$fn = 'echo_attributes';
				}
				$echo_attribs = '<?php static::'.$fn.'(array('.implode(',', $a).'));?>';
			} else {
				$echo_attribs = '';
			}

			if ($tag_attribs) {
				$a = array();
				foreach ($tag_attribs as $attr => $value) {
					if (preg_match('#^on((dbl)?click|mouse|key|(un)?load|abort|error|resize|scroll|select|change|submit|reset|focus|blur|dom*).*#', $attr)) {
						$this->error("Obsolete attribute '{$attr}' in node '{$name}'", E_USER_DEPRECATED);
					} else if (!strpos($attr,':') && !self::DTD_has_tag_attribute($name, $attr)) {
						$this->error("Unknown attribute '{$attr}' in node '{$name}'", E_USER_NOTICE);
					}
					$a[] = $attr.'="'.htmlspecialchars($value,ENT_COMPAT,'UTF-8',false).'"';
				}
				$tag_attribs = ' '.implode(' ', $a);
			} else {
				$tag_attribs = '';
			}

			/**
			 * Recreate a proper tag
			 */

			$tag = '<'.$name . $tag_attribs . $echo_attribs;
			if (self::DTD_is_empty_tag($name)) {
				$tag .= '/';
				$node['omit-tag'] = true;
			}
			$tag .= '>';
			if ('script' === $name && $this->DTD && isset($this->DTD['CDATA'])) { $tag .= $this->DTD['CDATA'][0].' '; }
			// Get <datalist> working in IE9, as IE9 removes <option> when using: <datalist><option>
			if ($this->HTML && 'datalist'===$name) { $tag .= '<select>'; }

			/**
			 * omit-tag option?
			 */

			if (isset($attribs['tal:omit-tag'])) {
				$exp = trim($attribs['tal:omit-tag']);
				if (strlen($exp))
				{
					$exp = self::parse_expression($exp);
					$code[] = 'if (!('.$exp.')) { ?>'.$tag.'<?php }';
					$node['php'][] = 'if (!('.$exp.')) { ?></'.$name.'><?php }';
				}
				$tag = '';
				$node['omit-tag'] = true;
			}
		}

		if ($code) { $this->data .= '<?php '.implode(' ', $code).' ?>'; }
		$this->data .= $tag;
		$this->nodes[$level] = $node;
	}

	// XML Parser: Node end
	protected function xml_node_end($parser, $name)
	{
		$level = --$this->level;
		if ($this->skip) {
			if ($level > $this->skip) { return; }
			$this->skip = 0;
		}
		if ($this->HTML || $this->isCaseInsensitive()) {
			$name = strtolower($name);
		}
		if ('tal' === $name) { return; }

		/** XSLT */
		if ($this->xslt_node_end($name, $level)) { return; }

		/**
		 * Default
		 */

		$node = array_pop($this->nodes);

		if (isset($node['translate'])) {
			$node['value'] = '<?php static::echo_data('.self::l10n($this->character_data).'); ?>';
		}
		$this->push_character_data($node['value'], 'script'!==$name);

		if (!$node['omit-tag']) {
			if ('script' === $name && $this->DTD && isset($this->DTD['CDATA'])) { $this->data .= ' '.$this->DTD['CDATA'][1]; }
			// Get <datalist> working in IE9
			if ($this->HTML && 'datalist'===$name) { $this->data .= '</select>'; }
			$this->data .= "</{$name}>";
		}
		if ($node['php']) {
			$this->data .= '<?php '.implode(' ', array_reverse($node['php'])).' ?>';
		}
	}
/*
	protected function xml_default_handler($parser, $data) { echo "xml_default_handler($data)"; }
	protected function xml_end_namespace_decl($parser, $user_data, $prefix) {}
	protected function xml_processing_instruction($parser, $target, $data)  {}
	protected function xml_start_namespace_decl($parser, $user_data, $prefix, $uri) {}
	protected function xml_unparsed_entity_decl($parser, $entity_name, $base, $system_id, $public_id, $notation_name) {}
*/

	/**
	 * XSLT
	 */
	protected function xslt_node_start($name, $attribs, $level)
	{
		if (0 !== strpos($name, 'xsl:')) { return false; }
		$this->character_data = '';
		switch (substr($name,4))
		{
		case 'choose':
			$this->xsl_choose[$this->level] = true;
			break;

		case 'for-each':
			$var = isset($attribs['as']) ? $attribs['as'] : preg_replace('#^.*/([^/]+)$#D', '$1', $attribs['select']);
			$this->push_character_data();
			$this->data .= '<?php ' . self::create_foreach($var, $attribs['select']) . ' ?>';
			$this->xsl_foreach[] = $var;
			break;

		case 'when': if (empty($this->xsl_choose[$level])) { break; }
		case 'if':
			$exp = $attribs['test'];
			$var = ($i = count($this->xsl_foreach)) ? $this->xsl_foreach[$i-1].'->' : '';
			$exp = preg_replace('#([^!=><])=#', '$1==', $exp);
			$exp = preg_replace('#(^|[^a-z>$])([a-z][a-z0-9_]+)([^a-z0-9_]|$)#D', '$1$ctx->'.$var.'$2$3', html_entity_decode($exp));
			$exp = str_replace($var.'position()', 'repeat->'.$var.'index', $exp);
			$exp = str_replace($var.'last()', 'repeat->'.$var.'last', $exp);
			$this->data .= '<?php if ('.$exp.') { ?>';
			break;
		case 'otherwise':
			if (!empty($this->xsl_choose[$level])) {
				$this->data .= '<?php else { ?>';
			}
			break;

		case 'value-of':
			$exp = $attribs['select'];
			if (strpos($exp,'/')) { $var = self::parse_expression($var); }
			else { $var = '$ctx->' . (($i = count($this->xsl_foreach)) ? $this->xsl_foreach[$i-1].'->' : '') . $exp; }
			$this->data .= '<?php static::echo_data('.$var.'); ?>';
			break;
		}
		return true;
	}

	protected function xslt_node_end($name, $level)
	{
		if (0 !== strpos($name, 'xsl:')) { return false; }
		switch (substr($name,4))
		{
		case 'for-each':
			array_pop($this->xsl_foreach);
			$this->push_character_data();
			$this->data .= '<?php } $ctx = $ctx->parent; ?>';
			break;

		case 'choose': unset($this->xsl_choose[$level+1]); break;
		case 'otherwise':
		case 'when': if (empty($this->xsl_choose[$level])) { break; }
		case 'if':
			$this->push_character_data();
			$this->data .= '<?php } ?>';
			break;

		case 'text': $this->push_character_data(); break;
		}
		return true;
	}

	protected static function create_foreach($var, $exp)
	{
		return '$ctx = $ctx->new_context_repeat(\''.$var.'\', '.self::parse_expression($exp).'); foreach ($ctx->repeat->'.$var.' as $ctx->'.$var.') {';
	}

	protected static function create_tmp_var($var)
	{
		static $count = 0;
		return '$_tmp_'.(++$count).'_'.$var;
	}

	protected static function parse_expression($exp, $def=null)
	{
		return Tales::translate_expression($exp, $def);
	}

	protected static function split_var_from_expression($exp)
	{
		// (dest, value) : (dest)
		return (preg_match('#^\s*([a-z0-9:\-_]+)\s+(.*)$#si', $exp, $m) ? array($m[1], trim($m[2])) : array(trim($exp), null));
	}

	protected static function getValue($expr, &$attribs)
	{
		if (!strlen($expr)) return '';

		$structure = (0===strpos($expr,'structure '));
		$expr = preg_replace('/^(text|structure)\s+/','',$expr);

		if (isset($attribs['i18n:translate'])) {
			unset($attribs['i18n:translate']);
			$expr = self::l10n($expr, true);
		} else {
			$expr = self::parse_expression($expr);
		}

		if ($structure) { return '<?php echo '.$expr.'; ?>'; }
		return '<?php static::echo_data('.$expr.');?>';
	}

	protected static function l10n($str, $parse=false)
	{
		$str = trim($str);
		# Look for tales modifier (not:, path:, string:, php:, etc...)
		if ($parse) {
			$str = self::parse_expression($str);
		} else {
			$str = Tales::string($str);
		}
		return '$this->L10N->get('.$str.')';
	}

	protected static function data2Str($data)
	{
		return '\''.addcslashes($data,"'\\").'\'';
	}
}
